You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
629 lines
19 KiB
629 lines
19 KiB
"use client";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
hasQuestionAnswerValue,
|
|
QuestionAnswersProvider,
|
|
useQuestionAnswers,
|
|
} from "@/components/questions/question-answer-storage";
|
|
import QuestionExitNavigationButton from "@/components/questions/question-exit-navigation-button";
|
|
import QuestionRenderer from "@/components/questions/question-renderer";
|
|
import QuestionSectionFlow from "@/components/questions/question-section-flow";
|
|
import TestIntroPage from "@/components/questions/test-intro-page";
|
|
import TestQuestionsFlow, {
|
|
type TestQuestion,
|
|
} from "@/components/questions/test-questions-flow";
|
|
import { DotsLoader } from "@/components/ui/button";
|
|
import NavigationButton from "@/components/ui/navigation-button";
|
|
import StickyHeader from "@/components/ui/sticky-header";
|
|
import { PageBackground } from "@/components/utils/page-background";
|
|
import { cattellFallbackQuestions } from "@/data/cattell-fallback";
|
|
import { glasserFallbackQuestions } from "@/data/glasser-fallback";
|
|
import {
|
|
getQuestionListItemBySlug,
|
|
isQuestionListItemVisibleForProfile,
|
|
isQuestionRequiredForProfile,
|
|
isQuestionVisibleForProfile,
|
|
type QuestionField,
|
|
} from "@/data/question-data";
|
|
import type { MarriageGender } from "@/hooks/marriage/types";
|
|
import {
|
|
useCattellQuestionsQuery,
|
|
useSubmitCattellAssessmentMutation,
|
|
} from "@/hooks/marriage/use-cattell";
|
|
import {
|
|
useGlasserQuestionsQuery,
|
|
useSubmitGlasserAssessmentMutation,
|
|
} from "@/hooks/marriage/use-glasser";
|
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
|
|
import { defaultLocale, type Locale } from "@/translations/config";
|
|
import { useI18n } from "@/translations/provider";
|
|
import AnswerPaceSheet from "./answer-pace-sheet";
|
|
|
|
type QuestionDetailClientProps = {
|
|
closeLabel: string;
|
|
continueLabel: string;
|
|
description: string;
|
|
informationLabel: string;
|
|
itemSlug: string;
|
|
locale?: Locale;
|
|
questionsListHref: string;
|
|
title: string;
|
|
};
|
|
|
|
type StoredQuestionField = {
|
|
label?: string;
|
|
value?: unknown;
|
|
type?: string;
|
|
key?: string;
|
|
};
|
|
|
|
type StoredAnswers = {
|
|
fields?: StoredQuestionField[];
|
|
};
|
|
|
|
function getQuestionStorageKey(slug: string) {
|
|
return `marriage:sections:${slug}:answers`;
|
|
}
|
|
|
|
function parseStoredAge(value: unknown) {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
const trimmedValue = value.trim();
|
|
|
|
if (!trimmedValue) {
|
|
return null;
|
|
}
|
|
|
|
const numericAge = Number(trimmedValue);
|
|
|
|
if (Number.isFinite(numericAge)) {
|
|
return numericAge;
|
|
}
|
|
|
|
const dateOfBirth = new Date(trimmedValue);
|
|
|
|
if (Number.isNaN(dateOfBirth.getTime())) {
|
|
return null;
|
|
}
|
|
|
|
const today = new Date();
|
|
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
|
const hasBirthdayPassed =
|
|
today.getMonth() > dateOfBirth.getMonth() ||
|
|
(today.getMonth() === dateOfBirth.getMonth() &&
|
|
today.getDate() >= dateOfBirth.getDate());
|
|
|
|
if (!hasBirthdayPassed) {
|
|
age -= 1;
|
|
}
|
|
|
|
return age >= 0 ? age : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getStoredAge() {
|
|
try {
|
|
const rawValue = window.localStorage.getItem(
|
|
getQuestionStorageKey("personal_info"),
|
|
);
|
|
|
|
if (!rawValue) {
|
|
return null;
|
|
}
|
|
|
|
const storedAnswers = JSON.parse(rawValue) as StoredAnswers;
|
|
const ageField = storedAnswers.fields?.find(
|
|
(field) =>
|
|
field.type === "number" ||
|
|
field.label === "Age" ||
|
|
field.label === "سن" ||
|
|
(typeof (field as any).key === "string" &&
|
|
((field as any).key.endsWith("_age") ||
|
|
(field as any).key.endsWith("_sn"))),
|
|
);
|
|
|
|
if (ageField) {
|
|
return parseStoredAge(ageField.value);
|
|
}
|
|
|
|
const dateOfBirthField = storedAnswers.fields?.find(
|
|
(field) =>
|
|
field.type === "date" ||
|
|
field.label === "Date of Birth" ||
|
|
field.label === "تاریخ تولد" ||
|
|
(typeof (field as any).key === "string" &&
|
|
((field as any).key.endsWith("_date_of_birth") ||
|
|
(field as any).key.endsWith("_tarykh_twld"))),
|
|
);
|
|
|
|
return parseStoredAge(dateOfBirthField?.value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function QuestionFlowWrapper({
|
|
visibleQuestions,
|
|
itemSlug,
|
|
dobQuestion,
|
|
dobQuestionIndex,
|
|
continueLabel,
|
|
questionsListHref,
|
|
}: {
|
|
visibleQuestions: QuestionField[];
|
|
itemSlug: string;
|
|
dobQuestion?: QuestionField;
|
|
dobQuestionIndex?: number;
|
|
requiredQuestionsCount: number;
|
|
continueLabel: string;
|
|
questionsListHref: string;
|
|
}) {
|
|
const { getAnswerValue } = useQuestionAnswers();
|
|
|
|
const dynamicQuestions = useMemo(() => {
|
|
return visibleQuestions.filter((question) => {
|
|
if (question.logic?.dependsOn) {
|
|
const { title, values } = question.logic.dependsOn;
|
|
const dependentQuestionIndex = visibleQuestions.findIndex(
|
|
(q) => q.title === title,
|
|
);
|
|
|
|
if (dependentQuestionIndex !== -1) {
|
|
const dependentQuestion = visibleQuestions[dependentQuestionIndex];
|
|
const answer = getAnswerValue(
|
|
dependentQuestion,
|
|
dependentQuestionIndex,
|
|
);
|
|
return values.includes(String(answer));
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [visibleQuestions, getAnswerValue]);
|
|
|
|
const requiredCount = useMemo(
|
|
() => dynamicQuestions.filter((q) => q.required).length,
|
|
[dynamicQuestions],
|
|
);
|
|
|
|
return (
|
|
<QuestionSectionFlow
|
|
key={itemSlug}
|
|
total={requiredCount}
|
|
continueLabel={continueLabel}
|
|
exitHref={questionsListHref}
|
|
optionalQuestionIndexes={dynamicQuestions.flatMap((question, index) =>
|
|
question.required ? [] : [index],
|
|
)}
|
|
questions={dynamicQuestions}
|
|
>
|
|
{dynamicQuestions.map((question, index) => {
|
|
const originalIndex = visibleQuestions.indexOf(question);
|
|
const answer = getAnswerValue(question, originalIndex);
|
|
const hasAnswer = hasQuestionAnswerValue(answer ?? null);
|
|
let isAnswered = hasAnswer;
|
|
|
|
if (hasAnswer) {
|
|
const isEmailQuestion =
|
|
question.title.toLowerCase().includes("email") ||
|
|
question.title.includes("ایمیل");
|
|
if (isEmailQuestion) {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
isAnswered = emailRegex.test(String(answer).trim());
|
|
} else if (question.type === "birthplace") {
|
|
const strVal = String(answer);
|
|
const parts = strVal.split(",").map((p) => p.trim());
|
|
isAnswered =
|
|
parts.length >= 2 && parts[0].length > 0 && parts[1].length > 0;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={`${itemSlug}-${question.title}`}
|
|
data-question-required={String(question.required)}
|
|
data-question-optional={String(!question.required)}
|
|
data-question-index={index}
|
|
data-question-original-index={originalIndex}
|
|
data-question-disabled="false"
|
|
data-question-answered={String(isAnswered)}
|
|
>
|
|
<QuestionRenderer
|
|
question={question}
|
|
questionIndex={originalIndex}
|
|
dobQuestion={dobQuestion}
|
|
dobQuestionIndex={dobQuestionIndex}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</QuestionSectionFlow>
|
|
);
|
|
}
|
|
|
|
export default function QuestionDetailClient({
|
|
closeLabel,
|
|
continueLabel,
|
|
description,
|
|
informationLabel,
|
|
itemSlug,
|
|
locale = defaultLocale,
|
|
questionsListHref,
|
|
title,
|
|
}: QuestionDetailClientProps) {
|
|
const router = useRouter();
|
|
const { dictionary: t } = useI18n();
|
|
const [isTestStarted, setIsTestStarted] = useState(false);
|
|
const { data: profile, isLoading: isProfileLoading } =
|
|
useMarriageProfileQuery();
|
|
const profileGender = profile?.gender;
|
|
const age = getStoredAge();
|
|
const item = getQuestionListItemBySlug(itemSlug, locale);
|
|
|
|
const isCattellSlug = itemSlug === "personality_test";
|
|
const isGlasserSlug = itemSlug === "glasser_5_needs_test";
|
|
|
|
const cattellQuery = useCattellQuestionsQuery(locale, {
|
|
enabled: isCattellSlug && isTestStarted,
|
|
retry: 0,
|
|
});
|
|
const submitCattellMutation = useSubmitCattellAssessmentMutation();
|
|
|
|
const glasserQuery = useGlasserQuestionsQuery(locale, {
|
|
enabled: isGlasserSlug && isTestStarted,
|
|
retry: 0,
|
|
});
|
|
const submitGlasserMutation = useSubmitGlasserAssessmentMutation();
|
|
|
|
const profileContext = useMemo(
|
|
() => ({
|
|
age,
|
|
gender: profileGender as MarriageGender | null | undefined,
|
|
}),
|
|
[age, profileGender],
|
|
);
|
|
|
|
const cattellTestQuestions: TestQuestion[] = useMemo(() => {
|
|
const questionsList =
|
|
cattellQuery.data?.questions && cattellQuery.data.questions.length > 0
|
|
? cattellQuery.data.questions
|
|
: cattellFallbackQuestions;
|
|
|
|
return questionsList.map((q) => {
|
|
const rawOptions =
|
|
q.options && q.options.length > 0
|
|
? q.options
|
|
: locale === "fa"
|
|
? ["بله", "به اندازه کافی واضح نیست", "نه"]
|
|
: ["Yes", "Not clear enough", "No"];
|
|
const mappedOptions = rawOptions.map((optText, idx) => ({
|
|
label: optText,
|
|
value: idx === 0 ? "A" : idx === 1 ? "B" : "C",
|
|
}));
|
|
return {
|
|
id: q.question_number,
|
|
text: q.text,
|
|
options: mappedOptions,
|
|
};
|
|
});
|
|
}, [cattellQuery.data]);
|
|
|
|
const glasserTestQuestions: TestQuestion[] = useMemo(() => {
|
|
const questionsList =
|
|
glasserQuery.data?.questions && glasserQuery.data.questions.length > 0
|
|
? glasserQuery.data.questions
|
|
: glasserFallbackQuestions;
|
|
|
|
const defaultGlasserOptions = [
|
|
{ label: locale === "fa" ? "خیلی کم (۱)" : "Very Low (1)", value: 1 },
|
|
{ label: locale === "fa" ? "کم (۲)" : "Low (2)", value: 2 },
|
|
{ label: locale === "fa" ? "متوسط (۳)" : "Moderate (3)", value: 3 },
|
|
{ label: locale === "fa" ? "زیاد (۴)" : "High (4)", value: 4 },
|
|
{ label: locale === "fa" ? "خیلی زیاد (۵)" : "Very High (5)", value: 5 },
|
|
];
|
|
|
|
return questionsList.map((q) => ({
|
|
id: q.question_number,
|
|
text: q.text,
|
|
info:
|
|
"factor" in q
|
|
? (q.factor as string)
|
|
: "factor_code" in q
|
|
? (q.factor_code as string)
|
|
: undefined,
|
|
options: defaultGlasserOptions,
|
|
}));
|
|
}, [glasserQuery.data, locale]);
|
|
|
|
const visibleQuestions = useMemo(() => {
|
|
if (!item) {
|
|
return [];
|
|
}
|
|
|
|
const hasDobQuestion = item.questions.some(
|
|
(q) => q.title === "Date of Birth" || q.title === "تاریخ تولد",
|
|
);
|
|
|
|
return item.questions
|
|
.filter((question) => {
|
|
if (
|
|
hasDobQuestion &&
|
|
(question.title === "Age" || question.title === "سن")
|
|
) {
|
|
return false;
|
|
}
|
|
return isQuestionVisibleForProfile(question, profileContext);
|
|
})
|
|
.map((question) => ({
|
|
...question,
|
|
required: isQuestionRequiredForProfile(question, profileContext),
|
|
}));
|
|
}, [item, profileContext]);
|
|
|
|
const requiredQuestionsCount = useMemo(
|
|
() => visibleQuestions.filter((q) => q.required).length,
|
|
[visibleQuestions],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isProfileLoading) {
|
|
return;
|
|
}
|
|
|
|
if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) {
|
|
return;
|
|
}
|
|
|
|
router.replace(questionsListHref);
|
|
}, [isProfileLoading, item, profileContext, questionsListHref, router]);
|
|
|
|
if (isProfileLoading && item) {
|
|
return (
|
|
<>
|
|
<PageBackground disabled />
|
|
<main className="-mx-[17px] flex h-svh flex-col items-center justify-center bg-[#F7F1F0]">
|
|
<DotsLoader className="text-[#F2465F] scale-150" />
|
|
</main>
|
|
</>
|
|
);
|
|
} else if (
|
|
!item ||
|
|
!isQuestionListItemVisibleForProfile(item, profileContext)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
if (item && item.questions.length === 0) {
|
|
if (isTestStarted) {
|
|
const isQuestionsLoading = isCattellSlug
|
|
? cattellQuery.isLoading || cattellQuery.isFetching
|
|
: isGlasserSlug
|
|
? glasserQuery.isLoading || glasserQuery.isFetching
|
|
: false;
|
|
|
|
if (isQuestionsLoading) {
|
|
return (
|
|
<>
|
|
<PageBackground disabled />
|
|
<main className="-mx-[17px] flex h-svh flex-col items-center justify-center bg-[#F7F1F0]">
|
|
<DotsLoader className="text-[#F2465F] scale-150" />
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const activeTestQuestions = isCattellSlug
|
|
? cattellTestQuestions
|
|
: isGlasserSlug
|
|
? glasserTestQuestions
|
|
: [];
|
|
|
|
if (activeTestQuestions.length === 0) {
|
|
const isError = isCattellSlug
|
|
? cattellQuery.isError
|
|
: isGlasserSlug
|
|
? glasserQuery.isError
|
|
: false;
|
|
const refetch = isCattellSlug
|
|
? cattellQuery.refetch
|
|
: glasserQuery.refetch;
|
|
|
|
return (
|
|
<>
|
|
<PageBackground disabled />
|
|
<main className="-mx-[17px] flex h-svh flex-col items-center justify-center gap-4 bg-[#F7F1F0] px-6 text-center">
|
|
<p className="font-semibold text-[#1B1B1B]">
|
|
{isError
|
|
? locale === "fa"
|
|
? "خطا در دریافت سوالات از سرور. لطفاً از اتصال اینترنت یا ورود به حساب کاربری اطمینان حاصل کنید."
|
|
: "Failed to load questions from server. Please check your connection or login status."
|
|
: locale === "fa"
|
|
? "سوالاتی برای این آزمون یافت نشد."
|
|
: "No questions found for this test."}
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsTestStarted(false)}
|
|
className="rounded-xl bg-[#EFEFEF] px-4 py-2 text-sm font-semibold text-[#1B1B1B]"
|
|
>
|
|
{closeLabel}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => refetch()}
|
|
className="rounded-xl bg-[#F2465F] px-4 py-2 text-sm font-semibold text-white shadow-md"
|
|
>
|
|
{locale === "fa" ? "تلاش مجدد" : "Retry"}
|
|
</button>
|
|
</div>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const handleTestFinish = async (
|
|
answers: Record<number, string | number>,
|
|
) => {
|
|
if (isCattellSlug) {
|
|
const responses = Object.entries(answers).map(([qNum, option]) => ({
|
|
question_number: Number(qNum),
|
|
option: String(option),
|
|
}));
|
|
try {
|
|
await submitCattellMutation.mutateAsync({ responses });
|
|
} catch {
|
|
// Ignore if already submitted or API returned error
|
|
}
|
|
try {
|
|
window.localStorage.setItem(
|
|
getQuestionStorageKey(item.slug),
|
|
JSON.stringify({ completed: true }),
|
|
);
|
|
} catch {}
|
|
} else if (isGlasserSlug) {
|
|
const responses = Object.entries(answers).map(([qNum, score]) => ({
|
|
question_number: Number(qNum),
|
|
score: Number(score),
|
|
}));
|
|
try {
|
|
await submitGlasserMutation.mutateAsync({ responses });
|
|
} catch {
|
|
// Ignore if already submitted
|
|
}
|
|
try {
|
|
window.localStorage.setItem(
|
|
getQuestionStorageKey(item.slug),
|
|
JSON.stringify({ completed: true }),
|
|
);
|
|
} catch {}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
};
|
|
|
|
return (
|
|
<TestQuestionsFlow
|
|
title={item.title}
|
|
questions={activeTestQuestions}
|
|
closeLabel={closeLabel}
|
|
informationLabel={informationLabel}
|
|
onClose={() => setIsTestStarted(false)}
|
|
onFinish={handleTestFinish}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const bulletKey =
|
|
item.slug === "glasser_5_needs_test" ? "glasser" : "personality";
|
|
const bullets = t.questions.testIntroBullets[bulletKey];
|
|
|
|
return (
|
|
<>
|
|
<PageBackground disabled />
|
|
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
|
|
<StickyHeader sticky={false} className="shrink-0">
|
|
<div className="flex items-center gap-4">
|
|
<NavigationButton
|
|
className="shrink-0"
|
|
variant="transparent"
|
|
icon="close"
|
|
iconLabel={closeLabel}
|
|
/>
|
|
<h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate">
|
|
{item.title}
|
|
</h1>
|
|
<NavigationButton
|
|
className="shrink-0"
|
|
variant="transparent"
|
|
icon="info"
|
|
iconLabel={informationLabel}
|
|
helpTitle={item.title}
|
|
helpDescription={description}
|
|
/>
|
|
</div>
|
|
</StickyHeader>
|
|
|
|
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-3 min-h-0">
|
|
<TestIntroPage
|
|
title={item.title}
|
|
estimateTime={item.estimate}
|
|
description={t.questions.testIntroEstimateLabel}
|
|
bulletPoints={bullets}
|
|
disclaimerText={t.questions.testIntroDisclaimer}
|
|
startLabel={t.questions.testIntroStart}
|
|
onStart={() => {
|
|
setIsTestStarted(true);
|
|
}}
|
|
/>
|
|
</div>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const dobQuestion = visibleQuestions.find(
|
|
(question) => question.title === "Date of Birth",
|
|
);
|
|
const dobQuestionIndex = visibleQuestions.findIndex(
|
|
(question) => question.title === "Date of Birth",
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageBackground disabled />
|
|
<AnswerPaceSheet
|
|
slug={item.slug}
|
|
title={title}
|
|
description={description}
|
|
continueLabel={continueLabel}
|
|
/>
|
|
|
|
<QuestionAnswersProvider slug={item.slug} questions={visibleQuestions}>
|
|
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
|
|
<StickyHeader sticky={false} className="shrink-0">
|
|
<div className="flex items-center gap-4">
|
|
<QuestionExitNavigationButton
|
|
className="shrink-0"
|
|
variant="transparent"
|
|
icon="close"
|
|
iconLabel={closeLabel}
|
|
exitHref={questionsListHref}
|
|
/>
|
|
<h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate">
|
|
{item.title}
|
|
</h1>
|
|
<NavigationButton
|
|
className="shrink-0"
|
|
variant="transparent"
|
|
icon="info"
|
|
iconLabel={informationLabel}
|
|
helpTitle={item.title}
|
|
helpDescription={description}
|
|
/>
|
|
</div>
|
|
</StickyHeader>
|
|
|
|
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-3 min-h-0">
|
|
<QuestionFlowWrapper
|
|
visibleQuestions={visibleQuestions}
|
|
itemSlug={item.slug}
|
|
dobQuestion={dobQuestion}
|
|
dobQuestionIndex={dobQuestionIndex}
|
|
requiredQuestionsCount={requiredQuestionsCount}
|
|
continueLabel={continueLabel}
|
|
questionsListHref={questionsListHref}
|
|
/>
|
|
</div>
|
|
</main>
|
|
</QuestionAnswersProvider>
|
|
</>
|
|
);
|
|
}
|